Skip to content

Store operation content once and enforce per-plan history retention - #35

Merged
amsultan2010 merged 2 commits into
mainfrom
operations-storage-and-retention
Aug 4, 2026
Merged

Store operation content once and enforce per-plan history retention#35
amsultan2010 merged 2 commits into
mainfrom
operations-storage-and-retention

Conversation

@amsultan2010

Copy link
Copy Markdown
Owner

What this changes

Coordination service, daemon, protocol, and docs: migration 012 drops operations.transaction and operation_files.payload so a transaction's file bodies live only in operations.event (they were stored three times), leaving operation_files as the per-path index into it. PgStore.pruneOperationsByRetention() now deletes each workspace's operations outside PLAN_LIMITS[plan].historyRetentionDays — strictly a prefix of the sequence — and records the highest deleted server_sequence in workspaces.operations_pruned_through, so GET /v1/operations can answer a cursor below that watermark with an explicit cursor-too-old resync status (or 410 Gone for daemons predating it, gated on a new protocolVersion parameter) instead of a truncated page that reads as "caught up"; the daemon adopts the watermark, records it, and surfaces the gap in status().service. The sweep runs on a service-side interval with its own privileged connection (CROSSCODE_RETENTION_DATABASE_URL), since the request-serving role deliberately cannot delete operations, and pnpm service:prune stays the manual tool.

Why

operations/operation_files were the only unbounded tables and the real hosting cost: every byte of afterContent was written three times, and the declared per-plan retention was never enforced because a naive age-based DELETE would hand a long-offline replica a short list it would silently mistake for being up to date.

Testing

GET /v1/operations is proven byte-identical before and after by comparing the serialized response against the same transaction read back through SELECT $1::jsonb (what the dropped column stored), and a sentinel census asserts the content appears exactly once across operations and zero times in operation_files. The silent-truncation case has dedicated tests at every layer: the store (asserting the naive query returns the empty list a replica would misread as "caught up"), the HTTP boundary, the protocol schemas, the daemon's resync path, and end-to-end over a real service, daemon, and Postgres.

  • pnpm build passes
  • pnpm test passes (305 passed)
  • pnpm test:postgres passes (24 passed across 7 files, none skipped)

Security / trust-boundary impact

The append-only guarantee on the request path is preserved: assertRuntimePrivileges still refuses a runtime role that can delete operations, and retention deletes only through a separate connection configured with CROSSCODE_RETENTION_DATABASE_URL, so no request-handling code path can reach a connection able to erase history (documented in docs/security.md). A resync only ever discards proposals a replica never downloaded — Git remains the source of truth, so no committed or working-tree work is at risk.

Related issues

None.

🤖 Generated with Claude Code

…tion

`operations` and `operation_files` were the only unbounded tables, and every
file body was written three times: once in `operations.event` (the envelope,
whose payload is the transaction), again in `operations.transaction`, and again
in `operation_files.payload`. Migration 012 drops the latter two, making the
envelope the single home for content and leaving `operation_files` as the
per-path index into it. GET /v1/operations is byte-identical either way, since
jsonb canonicalizes a value the same wherever it is stored.

Retention is now enforced against PLAN_LIMITS[plan].historyRetentionDays.
Pruning `operations` used to be unsafe because cursor reconnect cannot tell a
truncated list from "caught up", so the sweep deletes strictly a prefix of each
workspace's sequence and records how far it reached in
workspaces.operations_pruned_through. A cursor below that watermark is answered
with an explicit cursor-too-old resync status instead of a short page, and with
410 Gone for daemons that predate the status, so an older client cannot read it
as success. The daemon adopts the watermark, logs the gap, and surfaces it in
`status().service`. The sweep runs on a service-side interval with its own
privileged connection, because the request-serving role deliberately cannot
delete operations; `pnpm service:prune` remains the manual tool.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
crosscode Ready Ready Preview Aug 4, 2026 8:18am

Conflicts were three, all mechanical unions:

- store.ts imports and the migration list: main added the self-serve workspace
  cap and 012_rate_limits.sql, this branch added PLAN_LIMITS and its own
  migration. Both are kept, and this branch's migration is renumbered to 013
  since main took 012.
- store.integration.test.ts imports: both sides added some.
- BUILD_INSTRUCTIONS.md: main documented the free-tier abuse guards where this
  branch replaced the "retention is declared but not enforced" entry.

main also moved the API onto a function platform, which has no persistent
process to run the retention interval. Noted in serverless.ts alongside the
other things that do not survive that move, and in the README: until a platform
cron exists, that deployment needs `pnpm service:prune` driven externally.
Reads stay correct there regardless, since nothing being deleted means no
cursor is ever refused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@amsultan2010
amsultan2010 merged commit e43fc5e into main Aug 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant